Skip to content

feat: embed ethrex as the execution layer (in-process) - #530

Draft
pablodeymo wants to merge 10 commits into
mainfrom
feat/ethrex-inprocess-poc
Draft

feat: embed ethrex as the execution layer (in-process)#530
pablodeymo wants to merge 10 commits into
mainfrom
feat/ethrex-inprocess-poc

Conversation

@pablodeymo

@pablodeymo pablodeymo commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

Runs the execution layer in-process: ethrex is linked in as a library and driven by direct function calls. One binary — no Engine API, no JSON-RPC, no JWT, no second process to deploy or keep in sync.

Transactions work end to end: they can be submitted over HTTP, they propagate between the embedded execution layers over devp2p, and any proposer includes them.

This PR is in-process only. The out-of-process Engine-API integration remains #367; the two no longer overlap.

What Changed

New crate: crates/net/ethrex-engine

Wraps an ethrex Blockchain + Store and exposes only what the slot loop and transaction submission need:

pub async fn build_payload(parent_el_hash, timestamp, prev_randao, beacon_root, fee_recipient)
    -> Result<ExecutionPayloadV3, EngineError>;
pub fn       execute_payload(payload, parent_beacon_block_root) -> Result<(), EngineError>;
pub async fn set_head(head, safe, finalized)      -> Result<(), EngineError>;
pub async fn submit_raw_transaction(raw: &[u8])   -> Result<H256, EngineError>;
pub async fn start_p2p(config: P2PConfig)         -> Result<String, EngineError>;  // returns our enode

Deliberately not Engine-API shaped. In-process there is no latency to hide, so a payload is built and returned in one call — no payload id, no cache holding it between calls, no build-then-fetch two-step. Only consensus types and plain types cross the boundary (ExecutionPayloadV3, H256, [u8; 32], IpAddr); ethrex's own types stay behind it.

conversion.rs maps ExecutionPayloadV3 ⇄ ethrex Block against ethrex-common, so the heavyweight ethrex-rpc crate (Axum server + p2p stack) is not a dependency.

Consensus side

  • ExecutionPayloadV3 rides in the Lean block body, so peers execute the proposer's payload in their own embedded EL. The STF (process_execution_payload) checks its parent hash and slot timestamp; StateDiff carries the projected header so reconstructed states keep the EL block-hash chain.
  • Slot loop (crates/blockchain/src/el_integration.rs): build the payload at interval 4 where the next block is already assembled; execute arriving payloads before the store sees the block; execute our own block's payload; update the EL head at interval 0.
  • The build runs on spawn_blocking — it is a full EVM execution plus merkleization and the caller is the consensus actor. ethrex wraps its own callers the same way. The interval-0 head update is awaited rather than spawned, which makes the actor the execution layer's single caller and removes any need for locking.
  • Every path is permissive: an EL failure logs and falls back to a pass-through payload rather than stalling consensus. Only an explicit rejection of a received payload drops that block.
  • The consensus genesis is seeded with the EL genesis hash, read back from the engine itself rather than configured: the engine bootstraps from --el-genesis, so its startup head is the EL genesis block. Omitting this seed fails silently — the EL sits frozen at genesis while consensus looks healthy.

Transactions

Three pieces, in the order they were built:

Submission. POST /lean/v0/admin/el/tx with {"raw": "0x02f8…"} returns {"tx_hash": "0x…"}. This is the only way transactions enter the system, since ethrex-rpc is deliberately not a dependency and there is no eth_sendRawTransaction. ethrex does all the validation and its rejection message is passed through verbatim, because that is the part that tells you why your transaction did not land. A node without --el-genesis answers 501 — not 503, which would suggest retrying.

Propagation. --el-p2p-port joins the execution layers into their own mesh (discv4 + RLPx over TCP), independent of consensus gossip in every respect: separate key, port, and peer set. --el-bootnodes seeds discovery, and one enode is enough — each node logs its own at startup. Without this each mempool is isolated and a transaction waits for the turn of the node that received it. Four settings differ from ethrex's defaults, each for a specific reason documented at the call site: set_synced() must be called or inbound transactions are dropped silently; target_peers is 8 rather than 100 so nodes stop dialling each other indefinitely; discv4 stays on because disabling all discovery makes start_network discard the bootnode list; and the node binds 0.0.0.0 but advertises 127.0.0.1, since advertising the bind address propagates 0.0.0.0 over discv4.

The execution layer's key is derived from the consensus node key (keccak256("ethlambda-el-p2p" ‖ node_key)), not reused — both protocols use secp256k1, but libp2p's Noise handshake and discv4/RLPx auth are unrelated and should not share a secret. Still deterministic, so identity survives restarts.

Blobs. EIP-4844 transactions are accepted in the wrapped form, 0x03 || rlp([tx, wrapper_version, blobs, commitments, proofs]). See the blob caveat under Operational notes — they execute, but their data is not available.

Four correctness fixes

These were found by a design review of the first two commits, before transactions could reach any of them. All four are latent while payloads are empty and all four become live the moment a transaction can be submitted; two are network-wide and permanent. They are in their own commit (451fe29) and are reviewable independently.

Fix What went wrong without it
Evict included transactions from the mempool The builder re-fetches the already-included copy (no nonce filter), its re-execution fails, and ethrex's pop() drops every transaction from that sender. Each account could land exactly one transaction, ever.
Verify the payload's claimed block_hash Every other header field is rebuilt from the payload, so the hash is the only thing tying claim to contents. A mismatch would let each node's EL store a different block under a hash consensus already committed to.
Pass-through synthetic payload instead of zero The STF caches whatever block_hash a payload claims, so a zero moved the network's expected EL parent to a block nobody has — permanently, since every later build then failed the same way.
Build off the actor thread Blockchain::build_payload is a synchronous full EVM execution; awaiting it inline blocked the consensus actor and a tokio worker for the whole build.

The mempool one has a regression test that fails without the fix, with the second transaction silently absent from the block.

Dependencies

Every ethrex crate in the workspace is pinned to one revision, including crates/net/p2p's ENR helpers (ported to v15's typed NodeRecord). ethrex-crypto bundles a C SHA3 with non-namespaced symbols, so two ethrex versions multiply-define them under GNU ld — macOS ld64 tolerates it, which is why that only surfaces in the Linux release build.

ethrex-crypto is declared with default-features = false, and that is load-bearing: its defaults include kzg-rs, a second pure-Rust KZG implementation nothing here uses, and since cargo unifies features across the graph, enabling it once drags kzg-rs and its Plonky3 tree into every build — 468 lockfile lines including a duplicate p3-util, which has broken this workspace's MSRV before. Declared correctly the delta is a single dependency edge.


How to Run

Quick start — one command

./scripts/inprocess-devnet/run.sh --nodes 3 --slots 40 --trace --build

Three ethlambda nodes, each embedding its own ethrex, peered to each other on both networks. No separate EL containers and no lean-quickstart checkout: the script generates the validator keys, consensus genesis, ENRs, node keys and EL genesis itself. Requirements are just docker and yq — everything else runs in containers.

  • --build builds the node image first (slow once, cached after). Drop it on later runs.
  • --trace enables the EL trace logs. Needed for the payload build/execute counts to be visible — see the note below.

Actual output from the verification run:

▸ Starting 3 node(s) with an embedded execution layer
  ✓ ethlambda_0 (quic 9001, api 15052) [aggregator]
  ✓ EL bootnode: enode://474d0edd768962f29c...@127.0.0.1:30303
  ✓ ethlambda_1 (quic 9002, api 15053)
  ✓ ethlambda_2 (quic 9003, api 15054)
  ✓ all nodes alive
▸ Running to genesis + 4 slots (46s) before submitting a transaction
▸ Submitting a transaction to one node only (head slot 12)
  ✓ ethlambda_0 accepted it (0x199c42fc…)
▸ Looking for the transaction on chain
  ✓ found in the block at slot 13
▸ Verifying
  ✓ in-process EL enabled on 3/3 node(s)
  ✓ blocks produced: 48
  ✓ blocks imported from peers: 32
  ✓ finalized_slot=45 finalized_root=cff7e491 justified_slot=46
  ✓ EL payloads built: 48
  ✓ EL payloads submitted for execution: 144
  ✓ transaction accepted by 1/1 node(s) submitted to
  ✓ transaction included in the block at slot 13
  ✓ gasUsed=21000 in that block
  ✓ gossip proven: submitted to node(s) 0 , included by node 1
  ✓ EL devp2p started on 3/3 node(s)
  ✓ no synthetic fallbacks / rejected payloads
  ✓ no panics
✓ devnet run looks healthy

The script exits non-zero if any check fails, so it is usable in CI.

The transaction check is the one worth understanding: it submits to exactly one node and asserts a different proposer included it. Inclusion alone would prove nothing, since the receiving node proposes eventually anyway — a different proposer is the only real evidence the mesh works. To keep that deterministic it submits to the node that just proposed, which is a full rotation away from proposing again.

Variations

./scripts/inprocess-devnet/run.sh --nodes 1 --slots 10   # quick single-node smoke test
./scripts/inprocess-devnet/run.sh --trace --keep         # leave the nodes running
./scripts/inprocess-devnet/run.sh --no-el                # consensus-only control run
./scripts/inprocess-devnet/run.sh --no-el-p2p            # isolated mempools (pre-gossip behaviour)
./scripts/inprocess-devnet/run.sh --no-tx                # skip the transaction check

--no-el runs the same image with the execution layer switched off. That is how the parent_hash bug below was isolated: if something breaks, run it to see whether the EL is implicated. --no-el-p2p is the equivalent for the transaction mesh — it falls back to submitting to every node.

With --keep the nodes stay up:

docker logs -f ethlambda_0
curl -s localhost:15052/lean/v0/health

# submit a transaction (any node; it reaches the rest over devp2p)
curl -X POST localhost:15052/lean/v0/admin/el/tx \
  -H 'content-type: application/json' \
  -d "{\"raw\": \"$(cat crates/net/rpc/tests/fixtures/signed_transfer_nonce_0.hex)\"}"

docker rm -f ethlambda_0 ethlambda_1 ethlambda_2     # when done

Running a single node by hand

This is exactly what the script executes per node:

ethlambda \
  --genesis           /config/config.yaml \
  --validators        /config/annotated_validators.yaml \
  --bootnodes         /config/nodes.yaml \
  --validator-config  /config/validator-config.yaml \
  --hash-sig-keys-dir /config/hash-sig-keys \
  --node-id ethlambda_0 --node-key /config/ethlambda_0.key \
  --data-dir /data \
  --gossipsub-port 9001 --http-address 0.0.0.0 \
  --metrics-port 8081 --api-port 15052 \
  --el-genesis /config/el-genesis.json \
  --el-p2p-port 30303 \
  --is-aggregator
# ...and on every other node, additionally:
#   --el-bootnodes enode://<node 0's key>@127.0.0.1:30303

--el-genesis is the whole EL switch: present enables the embedded ethrex, absent runs a consensus-only node. --el-p2p-port additionally joins the transaction mesh; omit it and each mempool is isolated.

Four things that bite when hand-rolling this:

  1. The EL genesis must be Cancun (cancunTime: 0, no pragueTime). A Prague genesis makes ethrex demand a requests_hash that ExecutionPayloadV3 cannot carry, and every payload is rejected. The script refuses to start on one.
  2. At least one node needs --is-aggregator, or nothing finalizes.
  3. --validators takes annotated_validators.yaml, not validators.yaml — different schema; the latter is only node → [index].
  4. The enode for --el-bootnodes cannot be computed offline — the EL key is a keccak derivation of the consensus node key. Read it from the node's EL devp2p enabled log line.

Checking a run

grep -h "Embedded ethrex enabled"  ethlambda_*.log   # one line per node, identical hash
grep -h "EL devp2p enabled"        ethlambda_*.log   # one line per node, distinct enodes
grep -hc "Built execution payload" ethlambda_*.log   # needs --trace
grep -hc "EL executed payload"     ethlambda_*.log   # needs --trace
grep -hc "using synthetic payload\|EL rejected payload" ethlambda_*.log   # want 0

⚠️ Those EL lines are trace!, so without --trace a healthy run prints nothing about payloads — indistinguishable from an EL that never ran. The dependable INFO-level signal is the inverse: failures log at warn!, so silence there means success.

Unit / integration tests

cargo test --workspace --profile release-fast
cargo clippy --workspace --all-targets -- -D warnings

More detail in scripts/inprocess-devnet/README.md and docs/ethrex-inprocess-integration.md.


Verification

Check Result
cargo build --workspace, clippy -D warnings, fmt clean
Workspace tests 580 passed, 0 failed
3-node devnet with the EL finalized at slot 45 · 48 payloads built · 144 executed (48 × 3 nodes) · 0 fallbacks · 0 panics
Transaction submitted to node 0 only included by node 1 at the first opportunity, gasUsed=21000
Blob transaction accepted, included, blob_gas_used=131072; a second engine that never saw the sidecar executes the block
16-node devnet on ethlambda-2 (earlier commits) finalized 228→372→486 · 56/56 contiguous EL blocks · 0 synthetic · all nodes byte-identical

144 = 48 × 3 is the useful number: every node executed every block, so the new block_hash check accepted all 48 payloads on all three nodes rather than quietly rejecting any.

Operational notes

Three things to know before running this on a shared devnet.

  • The EL genesis hash changed. One alloc entry was added to fund 0xf39f…2266 (the standard Hardhat/Anvil account) so transactions have somewhere to come from — the fixture's 20 pre-existing prefunded accounts come from ethrex's execution-api.json and we do not hold their keys. A different EL genesis hash means a different consensus anchor, so an existing devnet needs a regenerated genesis.ssz at the next reset.
  • An EL-enabled node cannot be restarted. The EL store is in-memory while the consensus store is RocksDB, so a restarted node resumes consensus at its old slot with an EL rewound to genesis; since import is gated on EL execution, every gossiped block fails with ParentNotFound and the node never syncs again. The usual stop-wipe-checkpoint-sync recipe does not apply. Fix is either a persistent EL store or replaying payloads from the Lean store at startup — the Lean chain already contains every ExecutionPayloadV3, so that would be a complete EL sync with no new wire protocol. Tracked as gotcha 5.
  • Blob transactions execute, but blob data is not available. ExecutionPayloadV3 has no sidecar field, so blobs never cross the Lean network; they exist only in the mempools that received the transaction, and eviction-on-import discards those. BLOBHASH and the point-evaluation precompile still work, since they need only the versioned hashes. Blob support exercises the fee market and execution path, not DA. Also note wrapper_version must be 0 — version 1 is the EIP-7594 Osaka encoding, and current tooling often defaults to it. Tracked as gotcha 6.

Notes for reviewers

  • Suggested reading order and the decisions worth scrutinising are in docs/plans/scope-down-review.md, including the one bug the devnet caught (§6.1 — a proposer was building payloads on its own EL head instead of the parent consensus expects, which every peer then rejected; silent from the proposer's side, and impossible to catch with a single-EL unit test).
  • The four fixes in 451fe29 stand alone and can be reviewed without the transaction work. Each has a test; the mempool one fails without the fix.
  • Some code here arrived via feat: integrate with ethrex over the Engine API #367 and is load-bearing in-process tooExecutionPayloadV3 in the block body, process_execution_payload, the StateDiff header field, from_genesis_with_el_hash. Not Engine-API code: the proposer embeds the payload so peers can replicate execution in their own EL.
  • execute_payload is synchronous, so payload execution still happens on the actor thread (the build now does not). Acceptable at devnet scale; worth revisiting if execution time grows.
  • No fee-recipient configuration — Lean has no fee market or block rewards yet, so the EL is handed the zero address. Easy to make configurable when that changes.
  • No blob transaction in the devnet script, deliberately: it would need a ~260 KB checked-in hex fixture, and the cross-engine unit test already reproduces exactly what a peer does on import.
  • No changes to ethrex were required for any of this, including devp2p — its public API is sufficient at the pinned revision.

@pablodeymo pablodeymo changed the title feat: scaffold in-process ethrex execution-engine crate (phase 0) feat: in-process ethrex integration — engine core (phases 0-1) Jul 20, 2026
@pablodeymo pablodeymo changed the title feat: in-process ethrex integration — engine core (phases 0-1) feat: in-process ethrex integration (embedded EL, --execution-mode inprocess) Jul 21, 2026
Run the execution layer in-process: ethrex is linked in as a library and driven
by direct function calls. One binary, no Engine API, no JSON-RPC, no JWT.

New crate crates/net/ethrex-engine wraps an ethrex Store + Blockchain and
exposes the whole execution-layer surface as three methods:

  build_payload(timestamp, prev_randao, beacon_root, fee_recipient)
  execute_payload(payload, parent_beacon_block_root)
  set_head(head, safe, finalized)

The interface is deliberately not Engine-API shaped. In-process there is no
latency to hide, so a payload is built and returned in one call: no payload id,
no cache to hold it between calls, no build-then-fetch two-step. Only consensus
types cross the boundary; ethrex's own types stay behind it. conversion.rs maps
ExecutionPayloadV3 <-> ethrex Block against ethrex-common, so the heavyweight
ethrex-rpc crate (Axum server + p2p stack) is not a dependency.

Consensus side:
- ExecutionPayloadV3 rides in the Lean block body, so peers execute the
  proposer's payload in their own embedded EL; the STF checks its parent hash
  and slot timestamp, and StateDiff carries the projected header so
  reconstructed states keep the EL block-hash chain.
- The slot loop builds the payload inline at interval 4 (where the next block is
  already assembled), executes arriving payloads before the store sees the
  block, executes our own block's payload, and updates the EL head at interval 0.
  Every path is permissive: an EL failure logs and falls back to a synthetic
  payload rather than stalling consensus.
- The consensus genesis is seeded with the EL genesis hash, read back from the
  engine itself rather than configured — its absence fails silently, leaving the
  EL frozen at genesis while consensus looks healthy.

Every ethrex crate in the workspace is pinned to one revision, including the p2p
crate's ENR helpers: ethrex-crypto bundles a C SHA3 with non-namespaced symbols,
so two ethrex versions multiply-define them under GNU ld. macOS ld64 tolerates
it, which is why this only surfaces in the Linux release build.

--el-genesis <path> is the entire EL surface; omit it for a consensus-only node.
The genesis must be Cancun: a Prague genesis requires a requests_hash that the
Cancun-shaped ExecutionPayloadV3 cannot carry.

scripts/inprocess-devnet/run.sh runs a self-contained N-node devnet (no
lean-quickstart checkout needed) and checks the log evidence; the guide is in
docs/ethrex-inprocess-integration.md.

Workspace builds, clippy -D warnings clean, fmt clean, 299 tests pass.
A 3-node devnet with the embedded EL never finalized: 22 `parent_hash mismatch`
errors, peer imports halved, no aggregation coverage. A consensus-only control run
on the same image finalized normally, isolating the fault to the integration.

The state transition requires

    payload.parent_hash == state.latest_execution_payload_header.block_hash

which is the parent the consensus chain expects. build_payload instead derived it
from store.get_latest_canonical_block_hash(), the node's *own* EL head. Each node
runs an independent in-memory execution layer, so those two drift apart, and a
proposer's payload then named a parent no peer agreed with — every node's STF
rejected the block. It failed silently from the proposer's side: no EL rejection,
no warning, the block just never stuck.

The Engine-API path did not have this bug, because its build-mode
forkchoiceUpdated pointed the EL at el_hash_at(store.head()) before building.
Collapsing that two-step into a single call dropped the step that chose the parent.

build_payload now takes parent_el_hash explicitly and re-points the EL at that
block before building; el_integration passes el_hash_at(head_root). safe and
finalized are left unset in that fork-choice call, since pinning them would forbid
a later build on an earlier block.

Regression test builds_on_the_requested_parent_not_the_el_head advances the EL two
blocks, then builds on block 1 and asserts the payload names that parent; it fails
against the previous code. Unit tests could not have caught this — it needs more
than one execution layer to appear.

Verified: 3 nodes, 36 slots, finalized at slot 40 with all nodes on the same
finalized root and 43 payloads executed each; zero parent_hash mismatches, zero
synthetic fallbacks, zero panics. Matches the consensus-only control (slot 41).
@pablodeymo
pablodeymo force-pushed the feat/ethrex-inprocess-poc branch from 98921f4 to 0312cd6 Compare August 10, 2026 19:39
@pablodeymo pablodeymo changed the title feat: in-process ethrex integration (embedded EL, --execution-mode inprocess) feat: embed ethrex as the execution layer (in-process) Aug 10, 2026
All four are latent while every payload is empty, and all four become live the
moment transactions can enter the system. Two are network-wide and permanent.

Evict included transactions from the mempool on import. ethrex only calls
remove_block_transactions_from_pool from its Engine-API fork-choice handler,
which the in-process path bypasses, so nothing did. This is not merely a leak:
fetch_mempool_transactions applies no nonce filter, so the next build re-fetches
the already-included copy, its re-execution fails on the stale nonce, and
fill_transactions' pop() discards *every* transaction from that sender. Each
account could therefore land exactly one transaction, ever. The new test
sequential_transactions_from_one_sender_land_in_consecutive_blocks fails without
the fix, with block 2 carrying zero transactions.

Verify the payload's claimed block_hash before importing it. payload_to_block
rebuilds every header field from the payload's own contents, so the claimed hash
is the only thing tying claim to contents, and nothing compared them. With
user-controlled transaction bytes, any encode/decode asymmetry yields a block
every honest node imports while each node's execution layer stores a different
block under a hash the consensus chain already committed to. The check is
deterministic across nodes, so all nodes reject identically.

Make the synthetic-payload fallback a pass-through instead of zero. The state
transition caches whatever block_hash a payload claims and requires the next
payload's parent_hash to equal it, so a synthetic payload leaving block_hash at
zero moved the whole network's expected execution-layer parent to a block no node
has. Every later build would then be asked to extend it, fail, fall back to
synthetic again, and the execution layer would never produce another block on any
node. Repeating the parent pins the expectation to the last real block, so the
execution layer stalls for the skipped slots and resumes on the next successful
build. A node with no execution layer starts from a zero header and so is
unaffected.

Fill payloads on spawn_blocking. Blockchain::build_payload is a synchronous full
EVM execution plus merkleization and was awaited inline on the consensus actor's
task; ethrex wraps its own callers the same way. That widens the window for a
concurrent fork-choice update, so notify_execution_layer is now awaited rather
than spawned: the actor becomes the execution layer's single caller, which is
simpler than introducing a lock and costs only a store write at interval 0.

Also adds submit_raw_transaction, without which none of the above can be tested,
and funds 0xf39f...2266 (the standard Hardhat/Anvil account) in the EL genesis.
The fixture's 20 existing prefunded accounts come from ethrex's execution-api.json
and we hold none of their keys, checked against all three of ethrex's
fixtures/keys/private_keys*.txt. This changes the EL genesis hash and therefore
the consensus anchor, so an existing devnet needs a regenerated genesis.ssz.

Documents a fifth, deferred finding: the EL store is in-memory while the
consensus store is RocksDB, and block import is gated on EL execution, so a
restarted node fails every import with ParentNotFound and never syncs again. The
stop-wipe-checkpoint-sync restart recipe does not apply to an EL-enabled node.

Verified: 565 tests pass; fmt and clippy -D warnings clean; a 3-node devnet with
the embedded EL finalized at slot 45 having built 48 payloads and executed 144
(48 blocks x 3 nodes), with zero synthetic fallbacks, rejected payloads or panics.
… layer

Until now nothing could put a transaction into the system. ethrex is linked as a
library and the node deliberately does not depend on ethrex-rpc, so there is no
eth_sendRawTransaction, and add_transaction_to_pool had no caller. Every block
the chain has ever produced was empty.

POST /lean/v0/admin/el/tx takes {"raw": "0x02f8..."} and returns
{"tx_hash": "0x..."}. It is routed under /admin because it is a node-operator
affordance rather than part of the Lean consensus API. ethrex validates the
transaction itself — encoded size, duplicate hash, signature recovery, nonce,
balance, chain id, replacement rules — so the handler adds no validation of its
own and passes the mempool's rejection message through verbatim, which is the
part that tells a caller why their transaction did not land.

A node started without --el-genesis answers 501 rather than omitting the route,
so the difference is visible to a client instead of looking like a routing
mistake. That is deliberately not the 503 the aggregator endpoints return for a
missing controller: 503 invites a retry, whereas an execution layer is configured
at startup and will never appear.

The engine reaches the handler as an axum Extension, matching how the aggregator,
sync-status and event-bus handles are already threaded through start_rpc_server.
No trait and no new abstraction: ethlambda-ethrex-engine was already in this
crate's dependency graph via ethlambda-blockchain, so depending on it directly
only makes an existing edge explicit.

The devnet script now exercises the whole path. Four slots after genesis it posts
a signed transfer to every node, then scans the chain for those exact raw bytes
and asserts the containing block reports gasUsed > 0. It submits to every node on
purpose: without execution-layer gossip a transaction sits only in the mempool
that received it, so submitting to one node means waiting for that node's turn to
propose. Fanning out means whichever node proposes next includes it, and the
others evict it when they import the block.

The transaction is a checked-in hex fixture rather than signed at runtime, since
bash cannot sign and the RPC crate should not need secp256k1 just to build a test
input. ethlambda-ethrex-engine gains an ignored regenerate_rpc_fixtures test that
writes it, so it is reproducible rather than an opaque blob; rerun it if the EL
genesis chain id or funded account changes.

Verified: 575 tests pass, 10 of them new, covering the happy path, hex with and
without the 0x prefix, idempotent resubmission, the no-execution-layer case, and
five malformed-input cases. fmt and clippy -D warnings clean. On a 3-node devnet
all three nodes accepted the transaction, it was included in the first block
built after submission, and that block reports gasUsed=21000 with a receipts root
that differs from the empty-trie root.
A transaction submitted to one node reached only that node's mempool, so it sat
there until that particular node's turn to propose. On a 16-node devnet that is up
to 16 slots of waiting, and the workaround — submit to every node — does not scale
past a devnet the submitter happens to control.

Consensus already replicates *execution*: every block carries its payload, so
peers re-execute it locally. What consensus cannot carry is a transaction that has
not been included yet. This gives the execution layers their own mesh for exactly
that, using ethrex's devp2p (discv4 discovery + RLPx over TCP) alongside, and
entirely independent of, the consensus layer's libp2p. Separate key, separate
port, separate peer set. No changes to ethrex were needed; its public API is
sufficient at the pinned revision.

--el-p2p-port enables it and --el-bootnodes seeds discovery. One enode is enough:
discv4 finds the rest of the mesh from there, and each node logs its own at
startup.

Four settings differ from ethrex's defaults, each for a reason:

set_synced() is called after the stack is up. ethrex gates inbound Transactions,
NewPooledTransactionHashes and PooledTransactions on is_synced(), which defaults
false and is otherwise only set by the Engine-API fork-choice handler this
integration bypasses. Without it every inbound transaction is dropped *silently*,
which looks exactly like a mesh that never formed. It is correct for a
consensus-driven execution layer, which is never behind in the sense the flag
means, and no syncer is reachable from a P2PContext so it cannot start a snap
sync. Set in start_p2p rather than at construction, so a node running without
execution-layer gossip does not claim to be synced.

target_peers is 8 rather than 100. On a devnet every node is a candidate, so a
target above N-1 means the peer table is never full and every node keeps dialling
every other one forever. Nothing in ethrex sends DisconnectReason::AlreadyConnected
and new_connected_peer overwrites its entry, so simultaneous dials in both
directions leave duplicate connections carrying duplicate transaction traffic.

discv4 is on and discv5 is off. Discovery cannot be disabled outright: with both
off, start_network discards the bootnode list entirely, which would force every
node to know every other node's enode up front. discv5 would add a second protocol
surface and find nothing discv4 does not.

The node binds 0.0.0.0 but advertises 127.0.0.1. Advertising the bind address
would propagate 0.0.0.0 over discv4 and every dial back would fail.

The execution layer's key is derived from the consensus node key as
keccak256("ethlambda-el-p2p" || node_key) rather than reused. Both protocols use
secp256k1, but libp2p's Noise handshake and discv4 packet signatures plus RLPx
auth are unrelated, and one secret should not serve both. Deriving keeps the
identity deterministic across restarts, which the devnet script relies on.

The devnet script now proves propagation instead of assuming it: it submits to
exactly one node and asserts a *different* proposer included the transaction.
Inclusion alone would prove nothing, since the receiving node proposes eventually
regardless. To keep that deterministic it submits to the node that just proposed,
which is a full rotation away from proposing again. If the includer turns out to
be the submitter anyway, that is reported as gossip unproven rather than a
failure. --no-el-p2p restores isolated mempools and the fan-out submission.

Verified: 577 tests pass, fmt and clippy -D warnings clean. On a 3-node devnet all
three execution layers came up with distinct enodes and discv4 listening, a
transaction submitted to node 0 alone was included by node 1 at the first
opportunity, and that block reports gasUsed=21000. Chain finalized at slot 45 with
no synthetic fallbacks, rejected payloads or panics.
Blob (EIP-4844) transactions can now be submitted, included and executed. They
must arrive in the wrapped form, 0x03 || rlp([tx, wrapper_version, blobs,
commitments, proofs]) — the shape eth_sendRawTransaction takes — because the
mempool needs the sidecar to verify the KZG proofs and to build with later. The
bare form that appears inside blocks decodes to an empty bundle, so it is refused
with a message naming the wrapped encoding rather than an opaque bundle error;
sending the block form is the obvious mistake to make.

wrapper_version must be 0, one proof per blob. Version 1 is the EIP-7594 cell-proof
encoding for Osaka and later, and this EL genesis is Cancun. Current tooling often
defaults to version 1, so a transaction built by an up-to-date library is rejected
until told otherwise.

What this does NOT provide is data availability, and that is worth stating
plainly because the execution path works well enough to hide it.
ExecutionPayloadV3 has no sidecar field, so the blobs never cross the Lean
network; they exist only in the mempools that received the transaction, and the
eviction-on-import that transactions require in the first place discards those.
Nothing can retrieve them afterwards. BLOBHASH and the point-evaluation precompile
are unaffected, since they need only the versioned hashes and caller-supplied
data, so nothing inside the EVM notices. Treat blob support as exercising the fee
market and the execution path, not as a DA layer. Recorded as gotcha 6 in
docs/ethrex-inprocess-integration.md and on the endpoint in docs/rpc.md.

The load-bearing test is peer_executes_a_blob_block_without_ever_seeing_the_sidecar:
it builds a blob block on one engine and executes the payload on a second engine
that never saw the blobs. It passes because block validation derives blob gas and
count from blob_versioned_hashes alone and every KZG check lives on the
mempool-insertion path, never on import. That is what makes blob transactions safe
to include at all — had it failed, one blob transaction would fork the network — so
it is asserted rather than inferred from reading ethrex.

c-kzg is now declared explicitly on ethrex-blockchain. It already arrived through
ethrex-p2p's default features, but a c-kzg-gated API is called directly now, and
naming it keeps `cargo check -p ethlambda-ethrex-engine` and any
--no-default-features build honest.

ethrex-crypto joins the workspace for the KZG trusted-setup warm-up, pinned to the
same rev as every other ethrex crate and with default-features = false. That last
part is load-bearing: its defaults include kzg-rs, a second pure-Rust KZG
implementation nothing here uses, and because cargo unifies features across the
graph, enabling it once drags kzg-rs and its Plonky3 tree into every build — 468
lockfile lines, including a duplicate p3-util, which has broken this workspace's
MSRV before. Declared correctly the delta is a single dependency edge. ethrex
declares it the same way.

Verified: 580 tests pass, three of them new, fmt and clippy -D warnings clean. A
3-node devnet still finalizes with cross-node transaction gossip intact, confirming
the added feature and the warm-up do not disturb node startup.
…helper

Merging main brought in #552's `signed_block` test helper, written against a
`BlockBody` that has no `execution_payload`. This branch adds that field, so the
branch compiled and main compiled but their merge did not — a semantic conflict
with no textual conflict, which is why git reported the PR mergeable and only the
merge-commit build caught it.

The helper builds blocks for fork-choice tests that never reach the execution
layer, so the default all-zero payload is the right value.
Block import is gated on execution, so an execution layer that cannot extend the
consensus head is not degraded — it is terminal. Every arriving block names a
parent it does not have, fails, and is dropped before the store sees it. With the
execution store in memory, that was the state of every restarted node: consensus
resumed at its old slot, execution rewound to genesis, and the node never followed
the chain again. Checkpoint sync made it worse by moving the consensus head
further ahead.

Two parts. The execution store now persists in RocksDB under --data-dir/el, on the
same volume as the consensus store so it survives container recreation rather than
just a restart. And el_sync replays, at startup, whatever the execution layer is
missing: walk back from the consensus head to the last block it can actually
extend, then execute forward. The Lean chain carries every ExecutionPayloadV3 in
its block bodies, so it already is a complete execution history — no new wire
protocol and no peers needed.

Persistence turned out not to be sufficient on its own, which is worth recording:
a reopened datadir keeps the block index and canonical head but NOT executable
state. So the head is present and unbuildable, and the first attempt at gap
detection — "do I have this block?" — answered yes and concluded there was nothing
to do. The predicate is now can_build_on: the block is present AND its post-state
is reachable, which is the same condition ethrex checks before accepting a fork
choice update, and therefore the exact question build_payload will later ask.
Measured on a devnet restart: 44 payloads replayed in 8ms.

Also fixes a latent corruption risk found while testing this. The value seeded
into the consensus genesis anchor was read from head_hash(), which equals the
genesis hash only on a fresh store. With execution state persisted, a node whose
consensus datadir was wiped would have anchored a newly created genesis state to
whatever block its execution layer last executed, producing a chain no peer
agrees with — silently. Added genesis_hash() and used that.

start_p2p now runs after the resync rather than before. It calls set_synced(),
which is what unlocks inbound transaction gossip, and that should not happen while
the execution layer is still behind the chain.

The devnet harness gains --restart-node N. It stops a node, waits out the
gossipsub backoff, starts it, and then asserts what a node on a private fork
cannot fake: blocks imported from peers after the restart, and no synthetic-payload
fallbacks. The first version of this check asserted that the head slot advanced
and that the node was level with its peers — both of which a forked node satisfies
exactly, since a fork advances at the same rate. It passed while the node was
importing nothing. Head slot is now reported rather than asserted. Per-node checks
also count nodes instead of log lines, since a restart logs startup twice and
produced totals like "4/3 nodes".

Verified: 585 tests pass, three new, fmt and clippy -D warnings clean. On a 3-node
devnet, node 2 was stopped at slot 54 and restarted: it replayed 44 payloads,
imported 16 blocks from peers, built payloads with no synthetic fallbacks, and
reached slot 84 level with its peers. The chain finalized at slot 75 throughout,
with cross-node transaction gossip still working.
These ran the 16-node deployment on ethlambda-2 and lived in /tmp, which is the
wrong place for the only written record of how to launch this.

server-launch.sh exists because the launch is necessarily two-phase.
--el-bootnodes needs an enode URL that cannot be computed in advance: each node's
execution-layer key is a keccak derivation of its consensus node key, so the only
way to learn the enode is to ask the node. It starts node 0 alone, reads the enode
out of its log, and hands it to the rest; one bootnode is enough because discv4
finds the remainder of the mesh. It refuses to launch when the EL genesis has no
funded account, or when the subnet count disagrees with the genesis on disk —
either produces a devnet that looks healthy and cannot demonstrate anything.

server-demo-tx.sh submits one transaction to one node and reports which node
included it. It walks pre-signed transfers in nonce order and skips any already
spent, because the mempool rejects a replayed nonce and a demo that works exactly
once is not much of a demo. It submits to the node that just proposed — the one
furthest from proposing again — so inclusion by a *different* node is deterministic
rather than luck. That distinction is the whole point: inclusion alone would only
show that a mempool drains, while a different includer shows the transaction
travelled.

The generator for those transfers is an ignored test alongside the existing
fixture generator, parameterised by DEMO_TX_DIR and DEMO_TX_COUNT.

Verified in use: on the 16-node deployment, two consecutive runs submitted to
nodes 3 and 10 and were included by nodes 5 and 11 respectively, each with
gasUsed=21000, while the chain finalized normally.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant